Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit db40ee7b0256e85be81753b7dcc9f7ee2c556e34


Parents : f5531cc
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-06-05T22:02:39-05:00

feat(icon): refactor MaterialDesignIcon and related components to utilize centralized icon resolution logic, improving consistency and maintainability

Changes
Diff

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 5103736d..3fdc4892 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -610,7 +610,7 @@
<component
:is="Component"
v-if="!route.meta.keepAlive"
- :key="route.fullPath"
+ :key="route.meta.stableKey ? route.name : route.fullPath"
class="flex-1 min-w-0 h-full"
/>
</template>

diff --git a/meshchatx/src/frontend/components/MaterialDesignIcon.vue b/meshchatx/src/frontend/components/MaterialDesignIcon.vue
index 5cb29327..6567d829 100644
--- a/meshchatx/src/frontend/components/MaterialDesignIcon.vue
+++ b/meshchatx/src/frontend/components/MaterialDesignIcon.vue
@@ -17,12 +17,7 @@
</template>
<script>
-import * as mdi from "@mdi/js";
-
-const MDI_ICON_ALIASES = {
- mdiRoute: "mdiRoutes",
- mdiEmailSendOutline: "mdiSendOutline",
-};
+import { getMdiIconPath, resolveMdiIconKey } from "../js/mdiIconNames.js";
export default {
name: "MaterialDesignIcon",
@@ -35,42 +30,10 @@ export default {
},
computed: {
mdiIconName() {
- if (!this.iconName) return "mdiAccountOutline";
-
- // if already starts with mdi and is camelCase, return as is
- if (this.iconName.startsWith("mdi") && /[A-Z]/.test(this.iconName)) {
- return this.iconName;
- }
-
- // convert icon name from lxmf icon appearance to format expected by the @mdi/js library
- // e.g: alien-outline -> mdiAlienOutline
- return (
- "mdi" +
- this.iconName
- .split("-")
- .filter((word) => word.length > 0)
- .map((word) => {
- // capitalise first letter of each part
- return word.charAt(0).toUpperCase() + word.slice(1);
- })
- .join("")
- );
+ return resolveMdiIconKey(this.iconName);
},
iconPath() {
- if (!mdi || Object.keys(mdi).length === 0) {
- console.error("MDI library not loaded or empty");
- return "";
- }
-
- const name = this.mdiIconName;
- const aliasName = MDI_ICON_ALIASES[name] || name;
- const path = mdi[aliasName];
-
- if (path) return path;
-
- // fallback logic
- console.warn(`Icon not found: ${name} (original: ${this.iconName})`);
- return mdi["mdiHelpCircleOutline"] || mdi["mdiProgressQuestion"] || "";
+ return getMdiIconPath(this.iconName);
},
},
};

diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index cd50c7d7..5ce23dcb 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -1105,7 +1105,7 @@ import TileState from "ol/TileState";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
-import * as mdi from "@mdi/js";
+import { getMdiIconPath } from "../../js/mdiIconNames.js";
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
import { shared as olIconCache } from "ol/style/IconImageCache";
import { fromLonLat, toLonLat } from "ol/proj";
@@ -4986,15 +4986,8 @@ export default {
},
getMdiPath(iconName) {
if (!iconName) return null;
- // same logic as MaterialDesignIcon.vue
- const mdiName =
- "mdi" +
- iconName
- .split("-")
- .filter((word) => word.length > 0)
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
- .join("");
- return mdi[mdiName] || null;
+ const path = getMdiIconPath(iconName);
+ return path || null;
},
openChat(hash) {
this.$router.push({

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index c84c8c14..7115a59c 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -6402,6 +6402,11 @@ export default {
});
},
async markConversationAsRead(conversation) {
+ const wasUnread = conversation.is_unread === true;
+ if (!wasUnread) {
+ return;
+ }
+
// manually mark conversation read in memory to avoid delay updating ui
conversation.is_unread = false;

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 7a260c0c..4bfb6c99 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -490,10 +490,20 @@ export default {
this.persistPanes();
},
destinationHash(newHash) {
- if (newHash) {
- this.isMobileComposeModalOpen = false;
- this.onComposeNewMessage(newHash);
+ if (!newHash) {
+ return;
+ }
+ this.isMobileComposeModalOpen = false;
+ // Avoid a redundant reload when the route change originates from selecting a
+ // conversation/peer in this page. The focused pane already shows that peer, so
+ // re-running onComposeNewMessage would set selectedPeer to a different object
+ // reference for the same hash and re-trigger a full message reload (visible flash).
+ const currentHash = this.selectedPeer?.destination_hash;
+ const normalizedNew = Utils.normalizeMeshchatHashHex(newHash);
+ if (currentHash && Utils.normalizeMeshchatHashHex(currentHash) === normalizedNew) {
+ return;
}
+ this.onComposeNewMessage(newHash);
},
},
created() {

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index eb37359a..952a105b 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -44,7 +44,7 @@
import "vis-network/styles/vis-network.css";
import { Network } from "vis-network";
import { DataSet } from "vis-data";
-import * as mdi from "@mdi/js";
+import { getMdiIconPath } from "../../js/mdiIconNames.js";
import Utils from "../../js/Utils";
import GlobalEmitter from "../../js/GlobalEmitter";
import NetworkVisualiserLoadingOverlay from "./internal/NetworkVisualiserLoadingOverlay.vue";
@@ -488,16 +488,7 @@ export default {
});
},
getMdiIconSvg(iconName, foregroundColor) {
- const mdiIconName =
- "mdi" +
- iconName
- .split("-")
- .map((word) => {
- return word.charAt(0).toUpperCase() + word.slice(1);
- })
- .join("");
-
- const iconPath = mdi[mdiIconName] || mdi["mdiAccountOutline"];
+ const iconPath = getMdiIconPath(iconName);
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${foregroundColor}" d="${iconPath}"/></svg>`;
},

diff --git a/meshchatx/src/frontend/js/mdiIconNames.js b/meshchatx/src/frontend/js/mdiIconNames.js
index 465cb039..a604a6a8 100644
--- a/meshchatx/src/frontend/js/mdiIconNames.js
+++ b/meshchatx/src/frontend/js/mdiIconNames.js
@@ -4,6 +4,18 @@ import * as mdi from "@mdi/js";
export const DEFAULT_RRC_HUB_ICON = "forum-outline";
+const MDI_KEY_ALIASES = {
+ mdiRoute: "mdiRoutes",
+ mdiEmailSendOutline: "mdiSendOutline",
+};
+
+const MATERIAL_SYMBOL_ALIASES = {
+ "bug-report": "bug-outline",
+ "smart-toy": "robot-outline",
+ "robot-2": "robot-outline",
+ "emoji-objects": "lightbulb-on",
+};
+
let cachedIconNames = null;
function isKebabCaseIconName(name) {
@@ -57,3 +69,61 @@ export function normalizeMdiIconName(name) {
const trimmed = String(name).trim().toLowerCase();
return isValidMdiIconName(trimmed) ? trimmed : null;
}
+
+function splitIconParts(name) {
+ return name.split(/[-_]/).filter((word) => word.length > 0);
+}
+
+function kebabToMdiKey(kebab) {
+ return (
+ "mdi" +
+ splitIconParts(kebab)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join("")
+ );
+}
+
+export function normalizeIconNameForLookup(name) {
+ if (!name || typeof name !== "string") {
+ return "";
+ }
+ return name.trim().toLowerCase().replace(/_/g, "-");
+}
+
+export function resolveMdiKebabIconName(iconName) {
+ const normalized = normalizeIconNameForLookup(iconName);
+ if (!normalized) {
+ return null;
+ }
+ if (isValidMdiIconName(normalized)) {
+ return normalized;
+ }
+ if (MATERIAL_SYMBOL_ALIASES[normalized]) {
+ return MATERIAL_SYMBOL_ALIASES[normalized];
+ }
+ const withoutNumericSuffix = normalized.replace(/-\d+$/, "");
+ if (withoutNumericSuffix !== normalized && isValidMdiIconName(withoutNumericSuffix)) {
+ return withoutNumericSuffix;
+ }
+ return null;
+}
+
+export function resolveMdiIconKey(iconName) {
+ if (!iconName) {
+ return "mdiAccountOutline";
+ }
+ if (iconName.startsWith("mdi") && /[A-Z]/.test(iconName)) {
+ const aliasKey = MDI_KEY_ALIASES[iconName] || iconName;
+ return mdi[aliasKey] ? aliasKey : "mdiAccountOutline";
+ }
+ const resolvedKebab = resolveMdiKebabIconName(iconName);
+ const lookupName = resolvedKebab || normalizeIconNameForLookup(iconName);
+ const mdiKey = kebabToMdiKey(lookupName);
+ const aliasKey = MDI_KEY_ALIASES[mdiKey] || mdiKey;
+ return mdi[aliasKey] ? aliasKey : "mdiAccountOutline";
+}
+
+export function getMdiIconPath(iconName) {
+ const key = resolveMdiIconKey(iconName);
+ return mdi[key] || mdi.mdiHelpCircleOutline || mdi.mdiProgressQuestion || "";
+}

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index a9c0d967..a8b3c664 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -82,6 +82,7 @@ const router = createRouter({
name: "messages",
path: "/messages/:destinationHash?",
props: true,
+ meta: { stableKey: true },
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
},
{

diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index aa9ede1c..d6934c1d 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -99,6 +99,34 @@ describe("ConversationViewer.vue", () => {
});
};
+ it("markConversationAsRead skips server call and reload when conversation is already read", async () => {
+ const wrapper = mountConversationViewer();
+ await flushPromises();
+ axiosMock.post.mockClear();
+
+ const conversation = { destination_hash: "read-hash", is_unread: false };
+ await wrapper.vm.markConversationAsRead(conversation);
+
+ const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read"));
+ expect(markCalls).toHaveLength(0);
+ expect(wrapper.emitted("reload-conversations")).toBeFalsy();
+ });
+
+ it("markConversationAsRead marks read and reloads once when conversation is unread", async () => {
+ const wrapper = mountConversationViewer();
+ await flushPromises();
+ axiosMock.post.mockClear();
+
+ const conversation = { destination_hash: "unread-hash", is_unread: true };
+ await wrapper.vm.markConversationAsRead(conversation);
+ await flushPromises();
+
+ expect(conversation.is_unread).toBe(false);
+ const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read"));
+ expect(markCalls).toHaveLength(1);
+ expect(wrapper.emitted("reload-conversations")).toHaveLength(1);
+ });
+
it("onMessagePaste adds images from clipboard and prevents default", async () => {
const wrapper = mountConversationViewer();
const file = new File([""], "clip.png", { type: "image/png" });

diff --git a/tests/frontend/MaterialDesignIcon.test.js b/tests/frontend/MaterialDesignIcon.test.js
index 5b693ab4..7ea8c7be 100644
--- a/tests/frontend/MaterialDesignIcon.test.js
+++ b/tests/frontend/MaterialDesignIcon.test.js
@@ -10,6 +10,14 @@ describe("MaterialDesignIcon.vue", () => {
expect(wrapper.vm.mdiIconName).toBe("mdiAccountCircle");
});
+ it("resolves material symbol snake_case icon names", () => {
+ const wrapper = mount(MaterialDesignIcon, {
+ props: { iconName: "bug_report" },
+ });
+ expect(wrapper.vm.mdiIconName).toBe("mdiBugOutline");
+ expect(wrapper.vm.iconPath).not.toBe("");
+ });
+
it("renders svg with correct aria-label", () => {
const wrapper = mount(MaterialDesignIcon, {
props: { iconName: "home" },

diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index 804480a4..bb49656a 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -363,6 +363,41 @@ describe("MessagesPage.vue", () => {
expect(wrapper.vm.conversations[0].display_name).toBe("Peer");
});
+ it("does not reload the conversation when the route hash matches the selected peer", async () => {
+ const destHash = "b".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ // simulate a conversation already opened in the focused pane (as onPeerClick does)
+ wrapper.vm.selectedPeer = { destination_hash: destHash, display_name: "Peer" };
+ await wrapper.vm.$nextTick();
+
+ const composeSpy = vi.spyOn(wrapper.vm, "onComposeNewMessage");
+
+ // simulate router.replace propagating the same hash back into the prop
+ await wrapper.setProps({ destinationHash: destHash });
+ await wrapper.vm.$nextTick();
+
+ expect(composeSpy).not.toHaveBeenCalled();
+ });
+
+ it("composes the conversation when the route hash differs from the selected peer", async () => {
+ const selectedHash = "a".repeat(32);
+ const newHash = "b".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.selectedPeer = { destination_hash: selectedHash, display_name: "Peer" };
+ await wrapper.vm.$nextTick();
+
+ const composeSpy = vi.spyOn(wrapper.vm, "onComposeNewMessage").mockResolvedValue(undefined);
+
+ await wrapper.setProps({ destinationHash: newHash });
+ await wrapper.vm.$nextTick();
+
+ expect(composeSpy).toHaveBeenCalledWith(newHash);
+ });
+
it("uses conversation display name instead of Unknown Peer when composing", async () => {
const destHash = "c".repeat(32);
axiosMock.get.mockImplementation((url) => {

diff --git a/tests/frontend/mdiIconNames.test.js b/tests/frontend/mdiIconNames.test.js
index bc738725..731ac71e 100644
--- a/tests/frontend/mdiIconNames.test.js
+++ b/tests/frontend/mdiIconNames.test.js
@@ -2,8 +2,11 @@ import { describe, it, expect } from "vitest";
import {
DEFAULT_RRC_HUB_ICON,
buildMdiIconNames,
+ getMdiIconPath,
isValidMdiIconName,
normalizeMdiIconName,
+ resolveMdiIconKey,
+ resolveMdiKebabIconName,
} from "@/js/mdiIconNames.js";
describe("mdiIconNames", () => {
@@ -25,4 +28,15 @@ describe("mdiIconNames", () => {
expect(DEFAULT_RRC_HUB_ICON).toBe("forum-outline");
expect(isValidMdiIconName(DEFAULT_RRC_HUB_ICON)).toBe(true);
});
+
+ it("resolves material symbol snake_case names", () => {
+ expect(resolveMdiKebabIconName("bug_report")).toBe("bug-outline");
+ expect(resolveMdiIconKey("bug_report")).toBe("mdiBugOutline");
+ expect(getMdiIconPath("bug_report")).not.toBe("");
+ });
+
+ it("still resolves mdi kebab-case names", () => {
+ expect(resolveMdiKebabIconName("account-circle")).toBe("account-circle");
+ expect(resolveMdiIconKey("account-circle")).toBe("mdiAccountCircle");
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────